fix(metadata-protocol): stop persisting derived provenance keys, and restate tenant authorship at hydration for every type - #16796
Conversation
…ance keys wip Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…restatement Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…rip-derived-provenance-keys
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2037782843f0518a1c34791c0aad98e0acb892ed && git checkout 2037782843f0518a1c34791c0aad98e0acb892ed
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 625b0c32b4431ba5c04d7f898152a03a1f8a0251 e50b911ab90943da338b4151d5a4ac97c24653de && git checkout -B drift-repro 625b0c32b4431ba5c04d7f898152a03a1f8a0251 && git merge --no-ff e50b911ab90943da338b4151d5a4ac97c24653de
node scripts/docs-audit/affected-docs.mjs --json 625b0c32b4431ba5c04d7f898152a03a1f8a0251
|
Contract review (
|
| file | status |
|---|---|
.changeset/derived-provenance-write-door-and-hydration.md |
added |
packages/metadata-protocol/src/protocol.ts |
+106/−1 |
packages/objectql/src/protocol-derived-provenance-doors.test.ts |
added (440) |
packages/objectql/src/protocol-meta.test.ts |
4 pins updated |
packages/objectql/src/plugin.integration.test.ts |
1 pin updated |
packages/objectql/src/protocol-boot-hydration-scoped.test.ts |
1 pin updated + renamed |
Governed paths touched: NO (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/** — none). The documentation / tooling labels are the auto-labeler's, not a governed touch. Maintainer-only merge on governed grounds: no.
2. Clause-② re-derived independently: yes — and the body's own patch argument contradicts its own declaration
Re-derived from code, not from the body:
- Persisted metadata shape narrows.
saveMetaItem(protocol.ts:15006) now drops_packageId/_packageVersion/_provenancefrom every body it persists, for every type includingobject(the strip sits before the type branch). The storedsys_metadatabody is a persisted shape; it changed. - Hydrated registry-entry shape widens.
hydrateOverlayIntoRegistry(:14131) now registers{...data, _provenance: 'org'}for every non-objectrow with no artifact. That entry is whatSchemaRegistry.getItem/getAllApps/listItemsanswer — an exported objectql surface — and the PR's own six updated deep-equality pins are the measurement of that change. - The accept set of
PUT /meta/TYPE/NAMEis unchanged (the three keys are still accepted and silently dropped), so the request contract is not narrowed.
⇒ Clause-②: yes is correct. The body's argument that the act is "the opposite of additive" and therefore patch is where it goes wrong: the WHICH LEVEL ruling says a fix( that changes no public surface stays patch; this PR declares — correctly — that it changes one.
3. Changeset
- Package name
@objectstack/metadata-protocol: correct (the only package whosesrc/**moves; it is in thefixedgroup, version 17.3.0, not private). - Level
patch: wrong per the gate that enforces the batch [WIP] Add query enhancements and advanced validation features #35 ruling — see F1.scripts/check-changeset-no-major.mjsjudgeLevel: "enforce — declaredyes,patchon a grown package → exit 1". BothCheck Changesetruns on the head are red on exactly this. - No
**BREAKING**banner and no ADR-0087 disposition: correct — nothing an author can write is removed or renamed;check-adr-0087-registrationpasses. - FROM/TO: the body states what stops being persisted and what is restated; it does not state the served-document residual (F2). Nothing deliberately removed is silently dropped.
4. Tests
- New file
protocol-derived-provenance-doors.test.ts: realObjectQL+ realSchemaRegistry+ stub driver (not an engine double). No.skip/.only/.todo/skipIfin any of the four changed test files. - Would redden on revert, reasoned from code: the door-1 pins assert
not.toHaveProperty('_packageId'|'_packageVersion'|'_provenance')on the stored body; withoutstripDerivedProvenancethe repository'sputpersistsJSON.stringify(body)verbatim (sys-metadata-repository.ts:666) → red. The door-2 pins seed a row with_provenance: 'package'and assert the hydrated entry is'org'; withoutstateTenantAuthorshipthe hydrator registersdataas-is → red. The repro leg: with both doors absent,isCodeArtifactBody(metadata-core/src/code-artifact-provenance.ts) accepts truthy non-sentinel_packageId+ non-orgprovenance,getArtifactItem's bare-key fallback (registry.ts:3759/3777) returns the overlay,NOT_OVERRIDABLEfollows → red. Not vacuous. - Negative control (criterion 3) registers a real artifact under the composite key and asserts 403 both without and with a hydrated overlay: it discriminates an "unconditional allow" implementation.
- Criterion 5 asserts every artifact envelope key wins over both the stored copy and the
'org'stamp — the ordering contract of door 2 is pinned. - Typecheck program:
packages/objectql/tsconfig.test.jsonincludessrc/**/*; the new file has no entry in the shrink-only debt ledger, so it must compile at zero errors —Type Check · workspaceis green on the head. - Caveat the body admits: the suite resolves
@objectstack/metadata-protocolthroughdist/, so locally it reddens only after a rebuild. CI ordersBuild CorebeforeTest Core, so the gate is real there.
5. Persistence / hydration specifics
- Stops being persisted: exactly
_packageId,_packageVersion,_provenance, on thesaveMetaItemdoor only, for all types (objectincluded)._lock/_lockReason/_lockSource/_lockDocsUrlare persisted unchanged (pinned). - Restated at hydration:
_provenance: 'org'on a copy, for every non-objectrow reachinghydrateOverlayIntoRegistry(bootloadMetaFromDb, read-sidegetMetaItemson the unscoped kernel, and the meta overlay: a just-saved overlay is listed but not dispatchable for a short window — a cache between saveMeta and resolveRouteActionDeclaration lags the write #4521 write-through), applied beforemergeArtifactProtectionso an artifact's_provenance/_packageId/_packageVersion/_lock*still overwrite it. Theobjectlimbs (applyObjectRegistryMutation, bootregisterObjectbranch) are byte-for-byte untouched. - Backward read compatibility: a row written before this PR still parses and still hydrates (no schema change, no migration of at-rest bytes). It does not hydrate identically: its registry entry's
_provenancebecomes'org'where it was previously the stored value or absent — that is the fix, and the six updated pins are the measurement. But the served document for an at-rest poisoned row is not corrected by this PR — see F2. That is a backfill question, not a migration of this PR, and it must be stated.
6. Criterion-6 reading, spot-checked
The body's claim that getMetaItems re-stamps _packageId from the package_id column onto the body handed to the hydrator is confirmed at :7288-7290 ((data as any)._packageId = recPkg) → :7380 (hydrateOverlayIntoRegistry). Door 1 cannot reach it; door 2 makes it non-load-bearing because isTenantAuthored asks _provenance, not _packageId. The "both doors are not redundant" reasoning holds.
7. CI on head bbe4590c0
37 check runs: 29 success · 2 failure · 6 skipped · 0 in progress. Red: Check Changeset ×2 (pr-automation on opened and on labeled), both on the level axis above. Skipped: Packed-tarball smoke ×2 (opt-in), Auto Label, Check PR Size, Build Docs, Console Pin Gate. mergeable_state: unstable (the red gate). Head is 11 commits behind origin/main at review time (base recorded at CI: b38821d1; merge-base now 8b37a097). No reviews on the PR; one bot comment (docs-drift, advisory).
Findings
F1 — CHANGES REQUIRED. Changeset level patch is refused by the gate that enforces the batch #35 ruling, and the refusal is correct. The PR declares Clause-②: yes (and carries the needs:contract-review carrier) while grading @objectstack/metadata-protocol patch; check-changeset-no-major.mjs judgeLevel returns enforce, exit 1 — both Check Changeset runs are red. This seat's independent re-derivation agrees the surface changes (persisted body narrows; hydrated registry-entry shape widens). Expectation: take the gate's route 1 — change the one word in .changeset/derived-provenance-write-door-and-hydration.md to minor, and rewrite the body's "Changeset" paragraph so it no longer argues patch from "not additive" while declaring yes. ⛔ Do not take route 2 (flipping the declaration to no) — the declaration is the accurate half.
F2 — Must be stated in the PR body and changeset: door 2 corrects the authorization verdict, not the served document. getMetaItem step 1 (:7860-7875) serves the raw overlay row body with only _packageId stamped from the column and no _provenance restatement; mergeArtifactProtection only fires when an artifact exists. getMetaItems likewise serves the overlay data (which wins the merge over the restated registry entry). So a row poisoned at rest keeps being served as _provenance: 'package' by GET /meta/app/x — the UI keeps badging it as package-provided — until the row is re-saved (door 1) or backfilled by cloud#2069 / cloud PR #2111. The refusal is gone; the misattribution in the served body is not, for that population. Expectation: one sentence in the changeset and the body naming this residual and that the cloud backfill is load-bearing for it; carry it into the criterion-7 reply on cloud#2069 so cloud does not read "door is shut" as "backfill no longer needed".
F3 — Non-blocking, confirm the body's "noted, not filed" is complete. promoteDraft (:16942) and restoreVersion (rollbackMetaItem :20331, revertCommit :19941) hand a stored draft/history body straight to repo.put without stripDerivedProvenance. A rollback to a pre-PR history version therefore re-persists the three keys into the active row. Door 2 keeps that harmless for hydration; the corpus does not converge on those doors. Expectation: acceptable as scoped, but the body's claim "door 1 covers them transitively" is only true for bodies written after this PR — say so.
F4 — Minor. Door 2 restates only _provenance; a poisoned row's stale _packageVersion survives onto the hydrated entry (non-load-bearing for authorization, cosmetic until backfill). Expectation: none beyond F2's sentence.
F5 — Minor test gap. The strip is type-agnostic, so object bodies also lose the three keys at rest; the criterion-4 object case writes _packageId/_provenance into the request but asserts only the hydrated entry, not the stored body. Expectation: optional one-line not.toHaveProperty on the stored object row, so the type-agnostic scope of door 1 is pinned rather than implied.
F6 — Process, not a defect. The "both doors" decision was taken by triage + PM as a determinacy call, with an explicit invitation to rebut; the card's own text called it a maintainer call. This seat finds the determinacy argument sound (a stored copy is never load-bearing on the read side; only the lie is negated) and the ablation reasoning that the two doors cover different populations verified from code. Expectation: none — recorded so the maintainer sees the decision was a seat's, not theirs.
Summary for the maintainer: the fix is exactly what was dispatched, the pins discriminate, no governed path is touched. The single blocker is one word in the changeset (F1); F2 is a required disclosure, not a code change.
Generated by Claude Code
`check-changeset-no-major`'s level axis refuses a PR that declares `Clause-②: yes` while grading a package whose `packages/*/src/**` it moves as `patch`. Route 1 of the two the gate names: the declaration is right and the level is wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
CI is green on
|
…t` row F5 of the contract review on PR #16796. `stripDerivedProvenance` runs before `saveMetaItem`'s type branch, so an `object` body loses the same three keys at rest — the criterion-4 case wrote `_packageId` / `_provenance` into the request but read back only the hydrated entry, which door 2's restatement would answer `org` on its own even if the strip had skipped `object`. `_packageVersion` is now sent too, so all three keys the strip names are exercised rather than two. F2 of the same review, as a disclosure in the changeset: door 2 corrects the authorization verdict, not the served document, and the cloud#2069 backfill stays load-bearing for rows already poisoned at rest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Contract review (
|
| # | prior finding | state | evidence on e50b911ab |
|---|---|---|---|
| F1 | changeset patch refused under Clause-②: yes |
closed | .changeset/derived-provenance-write-door-and-hydration.md line 2: '@objectstack/metadata-protocol': minor (a35c00ffa2). Body's Changeset paragraph now opens on the declaration floor ("The floor comes from the DECLARATION, not from the direction of the change") and keeps the not-additive fact only as a parenthetical marked as not setting the floor. Check Changeset on this head: 2 runs (push 15:15Z, labeled 15:20Z), both success. |
| F2 | door 2 fixes the verdict, not the SERVED document; cloud#2069 backfill stays load-bearing | closed | Changeset: new final paragraph ("hydration corrects the AUTHORIZATION verdict, not the SERVED document … objectstack-ai/cloud#2069 backfill stays load-bearing … this change does not retire it"). Body: section "What door 2 does not fix: the SERVED document" + criterion-7 row. Probe checked against code, not the body: getMetaItem step 1 parses the row and stamps only _packageId from the package_id column (protocol.ts:7881-7884); no _provenance restatement on that path; envelope = resolveLockState(decorated, artifactBacked) (:8078), which in spec/src/kernel/metadata-protection.zod.ts:219-230 reads provenance straight from the item's _provenance and derives editable from lock alone, resettable = artifactBacked. For a poisoned row with no artifact that is exactly the pasted lock:"none" / provenance:"package" / editable:true / resettable:false. getMetaItems serves the overlay data (:7285-7293 → mergePackageAwareOverlay :7296 → mergeArtifactProtection(it, a) with a undefined, :7616-7628), while the hydrator gets stateTenantAuthorship(data) — a copy (:14131). Consistent. |
| F3 | say door 1 covers promote/rollback/revert only for post-PR bodies | closed | Body, Out-of-scope notes: "door 1 covers them transitively only for bodies written AFTER this PR. A rollback to a pre-PR history version re-persists the three keys into the active row". Accurate: stripDerivedProvenance has exactly one call site (:15006, saveMetaItem), the only repo.put is :15823 downstream of it; publishMetaItem → repo.promoteDraft (:16942), revertCommit → repo.restoreVersion (:19941), rollbackMetaItem → repo.restoreVersion (:20331) copy stored bodies inside sys-metadata-repository.ts, which this PR does not touch and which never references the three keys. |
| F4 | stale _packageVersion on hydrated entries |
accepted-as-recorded | stateTenantAuthorship (:1248-1251) sets only _provenance; body names the residual ("stale _packageVersion rides along … cosmetic until the backfill"). Nothing further owed. |
| F5 | pin the stored object row loses the three keys |
closed | protocol-derived-provenance-doors.test.ts:422-424: not.toHaveProperty on _packageId / _packageVersion / _provenance on the sys_metadata row read back via engine.find, plus toMatchObject({name, label}); the request now sends all three keys (:409-412). Ablation arithmetic is consistent: door-1 pins at :247-249 and :271-272 (2 cases) + this one = 3 red under leg A, was 2. (The leg-A run itself is the dev's measurement; not re-run here.) |
| F6 | both-doors decision is a seat's, not the maintainer's | accepted-as-recorded | No ruling exists (above). The triage invitation to rebut on the card is still open and nobody has taken it. |
Patch round bbe4590c0..e50b911ab — every file
.changeset/derived-provenance-write-door-and-hydration.md | 4 (+3/−1) patch→minor, F2 paragraph
packages/objectql/src/protocol-derived-provenance-doors.test.ts | 18 (+17/−1) F5 pin
Two files, nothing else. No protocol.ts change in the patch round — the source under review is byte-identical to what 5580303475 read (stripDerivedProvenance :1214, stateTenantAuthorship :1248, call sites :15006 / :14131, DERIVED_PROVENANCE_KEYS :1195). The PR body edits are not in the tree. Full diff vs merge-base is still 6 files, +622/−8, same set as the prior review.
Governed paths touched: NO — docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**: none in git diff --name-only 8b37a0973d..e50b911ab.
Clause-②: yes — stands as re-derived last round (persisted sys_metadata body narrows by three keys for every type; hydrated non-object registry entry widens by _provenance: 'org'). Level minor now matches the declaration. No **BREAKING** banner and none owed: check-adr-0087-registration's breakingDeclaration fires on major / **BREAKING / a type!: summary — the changeset has none of the three, and the job that runs it (pr-automation.yml:867-869, inside Check Changeset) is green on this head.
CI / merge state on e50b911ab
40 check runs: 34 success · 0 failure · 6 skipped · 0 in progress (skipped: Packed-tarball smoke ×2 opt-in, Auto Label and Check PR Size on the labeled re-run, Build Docs, Console Pin Gate). mergeable_state: clean. Head is 8 commits ahead / 78 behind origin/main (the base recorded on the PR, b38821d1, is older than the current merge-base) — the queue will re-merge; nothing in the diff overlaps a moving file that I can see, and No other open PR may claim the same single-writer path is green. Commit trailers: none of the 8 commits carries Fixes / Refs / Part of (the head commit's "F5 of the contract review on PR #16796" is prose, not a card-relation spelling); Part-of PR must not also close its card (RULE 2) is green ×2. Body carries Fixes #16702 and the PR is still draft, not queued, no auto-merge.
New findings (this round)
- Non-blocking, owed item — criterion 7 is still open and belongs to a seat with
cloudreach. The reply onobjectstack-ai/cloud#2069("door is shut; the backfill stays load-bearing for rows poisoned at rest — see the F2 residual") has not been posted; the PM seat recorded it as owed at landing (5579975456) andobjectstack-ai/cloudis not attached to any session that has worked this branch. Expectation: the director seat routes it to a seat withcloudattached, to post after this PR lands; the sentence must carry the F2 residual so cloud does not read "door shut" as "backfill retired". Not a merge condition of this PR. - Process, non-blocking — the PR body has no attribution footer. Confirmed: the body on this head ends at "Draft only. Not ready, not queued, no auto-merge." with no
---/_Generated by …_block.AGENTS.md("Write the attribution footer…", the PR body paragraph) prescribes the form and says "Durable attribution lives in body prose or a comment" and ⛔ "never re-send a body that already carries an appended footer" — it does not make a missing footer a merge condition, and the body carries none, so a single re-send with the session-URL form is permitted but not owed by the rule. Attribution is present in all 8 commit trailers (Claude-Session:+ the harnessCo-Authored-By, the one exemption the same paragraph names). Expectation: none for merge; the maintainer may re-send the body once with the session-URL footer if they want it stored. - Nit, optional. The new
objectpin's "nothing else was taken" line istoMatchObject({ name, label }), which cannot see an unexpected extra key on the stored row; the door-1 pins use the same shape, so this is consistent rather than a gap. Expectation: none; atoEqualagainst the exact stored shape would be strictly stronger if anyone touches the file again.
Maintainer-only merge: no
Grounds, on the whole picture rather than the governed axis alone: no governed path moves; no **BREAKING**, no !, no removed or renamed surface, no at-rest migration (a revert of this PR restores the previous behaviour byte-for-byte — rows written in between merely lack three keys the read side recomputes anyway); the minor bump is the declaration-driven floor, not a semver signal of widened API. The one open process point, F6, is a fix-shape decision the card handed to the maintainer and a seat took on a determinacy argument; that argument (a stored copy is never load-bearing for the authorization verdict; only the lie is negated) has now been verified from code twice, the F2 disclosure states precisely what the seat's choice does not fix, the rebuttal invitation on the card is still open, and the needs:contract-review carrier under Clause-②: yes — the compensating control for a low-tier dispatch — has been discharged by this review. Nothing here rises to the level that makes a merge maintainer-only; the decision stays visible to the maintainer on the card and in F6, which is what the record needs.
Generated by Claude Code
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 34249206284 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #16702
Clause-②: yes
The defect
saveMetaItempersisted the caller's_packageId/_packageVersion/_provenanceverbatim, and for every non-objecttype boot hydration read that stored body back as truth.metadata-read-decorations.tsdeliberately does not strip_provenancefrom a served document, so the ordinary StudioGET /meta/app/pet_hospitalthenPUT /meta/app/pet_hospitalround trip wrote_provenance: 'package'into the tenant's ownsys_metadatarow. From there the row's own bytes decided:isCodeArtifactBodyaccepted a truthy non-sentinel_packageIdwith non-orgprovenance,SchemaRegistry.getArtifactItem's bare-key fallback returned the overlay as an artifact,isArtifactBackedturned true, and every later write was refusedNOT_OVERRIDABLE— permanently, since the next boot re-derived the same verdict from the same row.The failure also misattributes: the operator is told the item is "provided by a code package" when no code package publishes it at all.
Both doors, because they cover different populations
Door 1 — the write door.
saveMetaItemnow drops exactly_packageId/_packageVersion/_provenancefrom the body it persists, one beat after the existingstripReadDecorationscall and for the same reason. The_lock*family is deliberately untouched: a lock is author-declarable and dropping one is the fail-open direction, the line cloud PR #2065 drew at its own producer.This is a new, write-door-scoped strip and not a new member of
METADATA_READ_DECORATIONS. That list is shared with every consumer that re-parses a served document, and its own header states on purpose that the protection envelope stays on a served body so provenance survives a re-parse.Door 2 — hydration.
hydrateOverlayIntoRegistry— the one choke point boot, read-side and write-through hydration already share — now states_provenance: 'org'on a copy beforemergeArtifactProtectionruns. That is the same sentenceapplyObjectRegistryMutationand the bootobjectlimb have always written, said once for every other type. The order is the whole contract: where a real artifact exists its envelope still overwrites_provenance(and_packageId/_packageVersion/_lock*) on the way out, so ADR-0010 section 3.3 precedence is unchanged in both directions.Door 2 is not belt-and-braces. Measured, not argued: with door 2 ablated and door 1 live, a row whose stored body carries no protection key at all is still hydrated as a code artifact, because
getMetaItemsre-stamps_packageIdonto the body from the row'spackage_idcolumn a few frames before handing it to the hydrator. The write-door strip cannot reach that path; the restatement can.Door 2 corrects the authorization verdict, not the served document.
getMetaItemandgetMetaItemsreturn the overlay row's own body —_packageIdstamped from the column, no_provenancerestatement — andmergeArtifactProtectiononly fires where an artifact exists. So a row already poisoned at rest becomes editable again whileGET /meta/app/xkeeps serving_provenance: 'package'and the UI keeps badging it as package-provided, until that row is re-saved through door 1 or backfilled. Theobjectstack-ai/cloud#2069backfill stays load-bearing for that population; this PR does not retire it, and the criterion-7 reply owes that sentence so cloud does not read "the door is shut" as "the backfill is no longer needed".Measured on this head, one poisoned-at-rest
approw with no artifact, through a throwaway probe deleted in the same round:editable: trueis door 2 working;"provenance":"package"in the same envelope is the residual. The stale_packageVersionrides along on the same rows and is cosmetic until the backfill.Acceptance criteria, and where each is asserted
New file
packages/objectql/src/protocol-derived-provenance-doors.test.ts— a realObjectQLengine, a realSchemaRegistry,sys_metadataregistered, no code package loaded.#16702 — the card's reproduction leg, run as written. Writes theappcarrying_provenance: 'package', boots a fresh engine + protocol over the same driver, callsloadMetaFromDb(), then saves again. Output pasted below._lock*door 1: one asserts the three keys are absent from the persisted body andname/labelsurvive; the other sends_lock/_lockReason/_lockSource/_lockDocsUrlalongside them and asserts all four survive the write.criterion 3. A real artifact registered under the composite keyapp.sdbh:pet_hospitalis still refusedNOT_OVERRIDABLE/ 403 — once with no overlay, and once after a boot that hydrated a tenant overlay of the same name.criterion 4. Theobjectbranch keeps stamping_provenance: 'org'and stays editable across a restart, and it still registers throughregisterObject(contributor layers) rather than the sharedregisterItempath —getObjectandgetAllObjects(packageId)both answer. The first case also asserts the storedobjectrow lost all three keys, pinning door 1's type-agnostic scope rather than implying it (the strip runs beforesaveMetaItem's type branch). Theobjectbranch source is untouched by this PR.mergeArtifactProtectionprecedence_lock: 'full',_lockReason,_packageId,_packageVersionand_provenance: 'package'while the overlay's authoredlabelstill wins; with no artifact the restatement stands and nothing is invented.row.package_idreadingobjectstack-ai/cloud#2069owes the SERVED-document residual named above, so cloud does not read "the door is shut" as "the backfill is no longer needed".objectstack-ai/cloudis not reachable from the sessions that have worked this branch, so posting that reply is handed to a seat that can reach it.Criterion 1, run as written
On
origin/main73053ed27b, the same path, byte for byte the card's own output:Criterion 6 — the reading on the
row.package_idcolumn pathTriage named one site and asked whether the column is a second poisoning path. The dispatch measured three. Re-derived by symbol on
origin/main73053ed27b(protocol.ts, 21,885 lines; firing controls on the same file:_packageId34 lines,_provenance12 lines, a nonsense control 0)::4922const packageId = row.package_id;— infoldStoredCollection. Stamps_packageIdonto a freshly parsed, ephemeral body that is pushed into the runtime authoring gate's reference-resolution universe and returned. Nothing persists it and nothing registers it. Not a poisoning path.:16054const packageId: string | null = row.package_id ?? null;— inmigrateStoredMetadata. Reads the column into the migration report row and passes it as thepackageIdparameter of the re-save, never as a body key. Not a poisoning path — and after door 1 an--applymigration pass now actively cleans the rows it rewrites.:19972packageId: row.package_id,— inrevertCommit, whererowis a commit row and the value goes torecordPackageCommitas a commit column. Not a metadata body at all. Not a poisoning path.But the pattern
row.package_idcannot express the shapes that matter. A whole-file census of thepackage_idspelling (61 occurrences) finds the cast and destructured forms it misses, and four of those DO write the column into a body::7208,:7354,:7736,:7805— all ingetMetaItems/getMetaItem, all(record as ...).package_id, eachif (recPkg && body._packageId === undefined) body._packageId = recPkg;.:7208is the live one. The sameoverlaysarray it stamps is handed tohydrateOverlayIntoRegistryat:7302on an unscoped kernel, so an ordinary list read re-injects_packageIdfrom the column into the in-memory registry entry. That is a genuine second path toisArtifactBacked, it needs no poisoned bytes at rest, and door 1 alone cannot close it. Door 2 does, becauseisTenantAuthoredasks_provenance, not_packageId. Asserted bythe read-side hydration seam (getMetaItems) is covered by the same restatement.No fix for the column path is folded into this PR. None is needed for the defect this card names: the stamp is a legitimate read-side surfacing of the row's package binding (the sidebar package filter consumes it), and door 2 makes it non-load-bearing for the authorization verdict.
Verification
Ablation — two legs, PLANT mode, blob-hash-verified restore. Each leg mutated
protocol.ts, rebuilt@objectstack/metadata-protocol(the suite resolves it throughdist/), and proved the marker reached the built artifact withscripts/ablation-dist-preflight.mjsbefore any colour was read.DERIVED_PROVENANCE_KEYSswapped for a nonsense key): marker present in 2 built files; 3 cases red — the two door-1 pins and theobjectstored-row pin added by the contract-review round (expected { name: 'pet_visit', …(6) } to not have property "_packageId"). It reddened 2 before that pin existed, which is the measurement that the pin discriminates._provenance: 'org'swapped for a non-orgliteral): marker present in 2 built files; 3 cases red — the two at-rest/read-side pins and the no-artifact restatement pin.git status --porcelainempty, rebuild plus--absentconfirms the marker is gone from all 24 built files, restored suite 11 of 11 green.One thing stayed green under both legs, and that is the finding, not a defect in the ablation: the reproduction leg itself. Either door alone closes it, which is exactly triage's "they cover different time windows". The layer holding it is the other door. The repro leg's own discriminating power is proven by the pre-fix run with neither door present, which reproduced the 403 verbatim.
Suites.
@objectstack/metadata-protocol169 files / 2,433 tests pass, 2 files / 10 cases skipped — named and counted: thedescribe.skipIf(!MYSQL_URL)limbs ofsrc/migrations/sys-setting-identity-index.live-mysql.test.ts(5) andsrc/migrations/seed-tenancy-backfill.live-mysql.test.ts(5), pre-existing and unrelated.@objectstack/objectql286 files / 4,819 tests pass, 0 skipped. Consumer sweep, because the served shape can change:@objectstack/rest192 files / 3,241 tests and@objectstack/runtime240 files / 3,340 tests, both fully green, 0 skipped.Typecheck legs, read out of each
package.jsonrather than assumed. metadata-protocol:tsc --noEmit, 1 leg. objectql:tsc --noEmit, thentsc --noEmit -p tsconfig.scripts.json, thencheck:test-typecheck(its own--self-testplus--package packages/objectql --project tsconfig.test.json) — 3 legs. All green.Existing pins updated, deliberately. Six cases asserted deep equality on a hydrated non-
objectregistry entry and now see one more key. Each was updated totoEqualagainst the exact new shape rather than relaxed totoMatchObject, so an unexpected extra key still reds them:protocol-meta.test.ts(4),plugin.integration.test.ts(1), andprotocol-boot-hydration-scoped.test.ts, whose "registers the row unchanged" case is renamed to what it actually pins — no artifact envelope is grafted, while_lockand_packageIdstay absent and_provenanceis now the server's own statement.Gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 59 families; all 59 were run and reconciled with--ran:57 exit 0. Two exit 3, which is
PREREQUISITE NOT METand is neither a pass nor a finding —check:dual-build-cjs-loadsandcheck:type-check-debtboth refuse to measure without a whole-workspace build closure, and both say so in their own output. They are declared to CI, which builds first.check:objectql-double-limitcaught a real defect in the new stub driver on its first run (afinddouble ignoring the caller's bound); corrected inbbe4590cand re-run green.Lint, as a proven narrowing rather than a skipped run. Population read from eslint's own config: 6,348 tracked files carry a lintable extension and none is removed by the single global-ignore block, whose whole content is
node_modules/dist/build/.next/.turbo— untracked build output that a tracked-file census matches 0 files under, as the config's own header records. Files linted, counted from--format json: 5, the entire lintable half of the change set,eslint --no-inline-configexit 0, 0 errors and 0 warnings. Invariance: this repo runs oneeslint.config.mjsand it never enables type-aware linting for any file (noparserOptions.project, no typed rules), so no verdict on any of the 6,343 untouched files can depend on this diff. Measured atbbe4590c.Changeset.
minor, on@objectstack/metadata-protocol. The floor comes from the DECLARATION, not from the direction of the change.check-changeset-no-major's level axis refuses any PR that declaresClause-②: yeswhile grading a package whosepackages/*/src/**it moves aspatch, and it never asks which way that surface moved. This PR declaresClause-②: yes(line 3 of this body) and movespackages/metadata-protocol/src/**, sominoris owed whatever the direction. (The act is not additive — no export is added and no key or value is newly accepted — but direction does not set this floor, and an earlier revision of this paragraph wrongly arguedpatchfrom it.) Route 1 of the two the gate names is taken: "The declaration is right and the level is wrong -> raise it tominor." Route 2 — correcting the declaration at the producer — is the review seat's and is deliberately not exercised: theClause-②:line is untouched and no tolerance was added to the gate. The release effect of the raise is nil:@objectstack/metadata-protocolsits in the singlefixedversion group in.changeset/config.json(one group, 70 members), so it already moves in lockstep with every other package in that group.none/ theskip-changesetlabel was rejected and stays rejected:AGENTS.mdis a floor againstnonefor a bug fix in a released package, not a ceiling, and this diff does publish from a released package. NoBREAKINGbanner and therefore no ADR-0087 disposition: nothing an author can write is removed or renamed, no export or config field changes, the three keys are still accepted on input, and where a real artifact exists the read side recomputes all three from it on every read — so there is no FROM-TO mapping to ship and no migration to prescribe.check-adr-0087-registrationagrees on this diff.验收备注
Triage's seven, adopted verbatim (
5578409434, quoted unchanged):Out-of-scope notes, filed nowhere
getMetaItem/getMetaItemsstamp_packageIdonto a served body from thepackage_idcolumn at four sites (:7208,:7354,:7736,:7805) and never stamp_provenancebeside it. Noted, not filed: with door 2 in place the asymmetry no longer decides an authorization verdict, so it is an observation about symmetry, not a reproducible defect.publishMetaItem,rollbackMetaItem(restoreVersion) andrevertCommitwrite bodies that did not come throughsaveMetaItemin this call. Each originates from a bodysaveMetaItemalready wrote (a draft, a history entry, a commit item) — but door 1 covers them transitively only for bodies written AFTER this PR. A rollback to a pre-PR history version re-persists the three keys into the active row, so the stored corpus does not converge through those paths; door 2 is what keeps that harmless, by making the stored copy non-load-bearing for the verdict. Noted, not filed: no repro, and the residual is the same one the section above names.Draft only. Not ready, not queued, no auto-merge.